iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0
Rust

Rust 後端入門系列 第 22

Day 22 Axum專案導入 Validator

  • 分享至 

  • xImage
  •  

為什麼要使用 validator

  • 提前驗證:比起讓 DB 拋錯再回 500/constraint error,API 在邏輯層就能回 4xx,使用者體驗與錯誤可追蹤性都比較好。
  • 宣告式規則:attribute 明確與可讀,團隊成員容易維護與擴充驗證規則。
  • 重用性:同一 DTO 在多個 handler 使用時,只要一處維護驗證規則即可。
  • 可測試:validator 的規則易於寫 unit tests,降低錯誤機率。
  • 結構化錯誤:自動收集多個欄位的錯誤,方便前端同時顯示多處問題。

實做

在現有 Cargo.toml 補上 validator 相關 crate(版本可視專案調整):

validator = "0.20"
validator_derive = "0.20"

在 DTO(models.rs)上用 derive 與 attribute

把要驗證的 DTO 加上 #[derive(Validate)],並在欄位上使用屬性。以下附上要改寫的 DTO。

use validator_derive::Validate;

#[derive(Deserialize, Validate)]
pub struct CreateUser {
		// 介於 3 到 50 字元之間
    #[validate(length(min = 3, max = 50))]
    pub username: String,
		
		// 驗證是否符合 email 格式
    #[validate(email)]
    pub email: String,

    // 密碼至少 8 字元
    #[validate(length(min = 8))]
    pub password: String,
}

// UpdateUser:Option 欄位在 Some 時套用驗證
#[derive(Deserialize, Validate)]
pub struct UpdateUser {
    #[validate(length(min = 3, max = 50))]
    pub username: Option<String>,

    #[validate(email)]
    pub email: Option<String>,

    #[validate(length(min = 8))]
    pub password: Option<String>,
}

// LoginRequest:也可加驗證
#[derive(Serialize, Deserialize, Clone, Validate)]
pub struct LoginRequest {
    #[validate(length(min = 1))]
    pub username_or_email: String,

    #[validate(length(min = 8))]
    pub password: String,
}

說明

  • #[validate(email)]:驗證欄位是否為合法 email 格式。
  • #[validate(length(min = x, max = y))]:檢查字串長度範圍。
  • Option 欄位也支援驗證:當值是 None 時不檢查,Some(v) 時會檢查 v。

在 handler 內呼叫 validate() 並處理錯誤

在 handler(例如 create_user、update_user、login)接收 Json(payload) 後,先呼叫 payload.validate()。以下示範如何把 validator 錯誤轉成可回傳的 JSON。

  • 把 validator::ValidationErrors 轉成結構化 JSON
use validator::{Validate, ValidationErrors};

// 把 ValidationErrors 轉成 { "errors": { "field": ["msg1", "msg2"], ... } }
pub fn validation_errors_to_json(errs: &ValidationErrors) -> serde_json::Value {
    use serde_json::Value;
    let mut map = serde_json::Map::new();

    for (field, errors) in errs.field_errors().iter() {
        let messages: Vec<String> = errors.iter().map(|fe| {
            // 優先使用 message,若無則使用 code(或其他 fallback)
            if let Some(msg) = &fe.message {
                msg.clone().to_string()
            } else {
                fe.code.to_string().into()
            }
        }).collect();
        map.insert(field.to_string(), Value::Array(messages.into_iter().map(Value::String).collect()));
    }

    json!({ "errors": Value::Object(map) })
}
  • create_user
pub async fn create_user(
    Extension(pool): Extension<PgPool>,
    Extension(mut redis): Extension<MultiplexedConnection>,
    Json(payload): Json<CreateUser>,
) -> Result<impl IntoResponse, AppError> {
    // DTO 驗證(同步)
    if let Err(e) = payload.validate() {
        let body = validation_errors_to_json(&e);
        return Err((StatusCode::BAD_REQUEST, body.to_string()));
    }	

    // 驗證通過後,再進行密碼雜湊與 DB 操作
    
    // ...
}
  • login :同樣先 validate,若驗證通過再做 DB 查詢與密碼比對
pub async fn login(
    Extension(pool): Extension<PgPool>,
    Json(payload): Json<LoginRequest>,
) -> Result<impl IntoResponse, AppError> {
    if let Err(e) = payload.validate() {
        let body = validation_errors_to_json(&e);
        return Err((StatusCode::BAD_REQUEST, body.to_string()));
    }

    // 驗證通過 → 查 DB、驗證密碼
}

錯誤回傳格式建議

建議回傳 400 Bad Request,body 為結構化 JSON,例如:

{
"errors": {
"email": ["invalid email"],
"password": ["length must be at least 8"]
}
}

這樣前端可以根據欄位顯示 field-level error。也可以把第一個錯誤抽出來回傳更簡潔的訊息,但建議保留完整錯誤以利除錯。

測試

符合格式,正常建立用戶

POST http://127.0.0.1:3000/users

{

"username": "user2",
"email": "user2@a.com",
"password": "password"

}

格式不符,回傳錯誤

POST http://127.0.0.1:3000/users

{

"username": "",
"email": "not-email",
"password": "1"

}

回傳400 與錯誤JSON

{
	"errors": {
		"email": [
			"email"
		],
		"password": [
			"length"
		],
		"username": [
			"length"
		]
	}
}

因為格式不符,登入被拒的範例

POST http://127.0.0.1:3000/users/login

{

"username_or_email": "",
"password": "1"

}

回傳的JSON

{
	"errors": {
		"password": [
			"length"
		],
		"username_or_email": [
			"length"
		]
	}
}

實務建議與注意事項

  • 把同步格式驗證(email 格式、長度、regex)放在 DTO + validator;把依賴外部資源的驗證(唯一性、是否存在於 DB)留在 handler(因為可能需要 async 查詢)。
  • 不要在驗證錯誤回傳中包含使用者密碼或其他敏感資訊。
  • 若需要更複雜的欄位依賴驗證(例如 password 與 confirm_password 要相等),validator 支援自訂函式(#[validate(custom = "your_fn")]),也可在 handler 做跨欄位檢查。

自訂驗證函式範例(跨欄位或更複雜)

  • validator 支援自訂函式:例如密碼強度檢查或禁用某些 username:
fn validate_password_strength(pw: &str) -> Result<(), validator::ValidationError> {
    if pw.chars().any(|c| c.is_ascii_punctuation()) && pw.len() >= 8 {
        Ok(())
    } else {
        Err(validator::ValidationError::new("weak_password"))
    }
}

#[derive(Deserialize, Validate)]
pub struct CreateUser {
    // ...
    #[validate(length(min = 8))]
    #[validate(custom = "validate_password_strength")]
    pub password: String,
}

上一篇
Day 21 Axum 專案整合 CORS/Tracing
下一篇
Day 23 Axum 專案整合測試
系列文
Rust 後端入門25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言